Skip to content

Add support for compressed QMDL#970

Merged
bmw merged 5 commits into
mainfrom
qmdl-gzip
Jun 23, 2026
Merged

Add support for compressed QMDL#970
bmw merged 5 commits into
mainfrom
qmdl-gzip

Conversation

@wgreenberg

Copy link
Copy Markdown
Collaborator

this is prerequisite work for #81, since the diag logs for things like RSSI massively increase the size of QMDL files. in my experience, simply gzipping the qmdls reduces their size by 4-5x, which i think should be sufficient for our purposes.

this PR reworks QmdlWriter to output gzipped QMDL files by default, and allows QmdlReader to operate on either compressed or uncompressed QMDLs.

QmdlReader has been significantly rewritten to expose a single AsyncRead interface to both compressed and uncompressed QMDL sources.

i'd still like to do some more in-depth testing of this, but in the meantime i'd love a review on it

@untitaker untitaker mentioned this pull request Apr 1, 2026
7 tasks
Comment thread lib/src/qmdl.rs Outdated
self.total_written += msg.data.len();
// for a gzipped file, we can't use `msg.data.len()` to
// determine the number of bytes written, so we have to
// manually do a `write_all()` type loop

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not understanding this and i'm hoping you can help me

if what we're tracking here is the number of uncompressed bytes, why can't we still use write_all and msg.data.len()?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah you're totally right, this is an outdated comment/implementation from when i was trying to track total_bytes_written rather than total_uncompressed_bytes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah ok. that makes sense

i do want to flag tho that when i try changing this back to use write_all, the tests fail so there may be another reason to use this approach which i also don't understand right now lol

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wager it's due to a missing .flush() call -- i'll push a commit w/ working tests in a sec

@bmw bmw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i didn't make the time to really test this (at least not yet), but i wanted to share the comments i had after reading the code and playing with things just a little bit so you could continue working on this

for my benefit at least as much as yours for when i come back to this, my comment above about continuing to use write_all in write_container has not been fully addressed yet

Comment thread check/src/main.rs Outdated
Comment thread lib/src/qmdl.rs Outdated
Comment thread daemon/src/qmdl_store.rs Outdated
Comment thread lib/src/qmdl.rs Outdated
Comment thread daemon/src/server.rs Outdated
Comment thread daemon/src/server.rs Outdated
Comment thread lib/src/qmdl.rs Outdated
Comment thread lib/src/qmdl.rs
Comment thread check/src/main.rs Outdated
@cooperq cooperq mentioned this pull request Apr 24, 2026
23 tasks
@wgreenberg

wgreenberg commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

@bmw @untitaker sorry about the protracted delay, but i've substantially reworked this based on some of your comments. since you last reviewed it, here are the major changes:

  • renamed the confusing/redundant names in lib/src/qmdl.rs
  • reworked QmdlMessageReader (formerly QmdlReader) to automatically detect a compressed file based on the first couple bytes. we can do this safely since no diag container DataType value could equal Gzip's gargantuan magic number 0x1f8b
  • reworked the QMDL reading process to rely on valid HDLC frames rather than reading up to some boundary. this greatly simplifies a lot of our code that reads QMDL files, removes the confusing bookkeeping of compressed/uncompressed file sizes, and i think overall gives us more robust file reading
  • reworked QmdlMessageReader's API to be oriented around diag Messages instead of MessagesContainers. the old way existed because long ago this code shared a trait with the DiagDevice struct, so it had to pretend it was reading the MessagesContainer structs when both it and its consumers only care about Messages

i'll mark your previous review comments as resolved since i believe i addressed them all. thanks for your patience!

@wgreenberg wgreenberg marked this pull request as ready for review June 4, 2026 02:25
@wgreenberg

Copy link
Copy Markdown
Collaborator Author

oops, lemme resolve the merge conflicts first

Major changes:
* QmdlWriter now outputs gzipped QMDL files by default
* QmdlReader renamed to QmdlMessageReader, and reads both compressed and
  uncompressed QMDL. It no longer requires bounding to avoid reading
  partially written files.
@wgreenberg

Copy link
Copy Markdown
Collaborator Author

sorry for the force push, but rebasing with so many old commits that i majorly reworked was just untenable. this is review-ready again!

@bmw

bmw commented Jun 4, 2026

Copy link
Copy Markdown
Member

just posting this in case it helps you to see it, but it looks like clippy is still mad here

@wgreenberg

Copy link
Copy Markdown
Collaborator Author

bleh, i guess my clippy was slightly outdated and missed a suggestion.

@bmw bmw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i still want to manually test this a bit more before merging, but i think we're getting close!

nice work here wgreenberg. this strikes me as a tricky and thorny project

Comment thread lib/src/diag.rs Outdated
}
}

impl From<MessagesContainer> for Bytes {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need or want this? when i delete this and the use bytes::Bytes; above and run tests on my fork everything passes

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, i think this is leftover from either a test i deleted, or possibly from serializing to an axum Body. in any case, you're right that it's superfluous

Comment thread lib/src/qmdl.rs Outdated
Comment on lines 48 to 49
self.writer.flush().await?;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a small thing i noticed playing with GzipEncoder is the compression gets better if you call flush less often. because of that, what do you think about making a change like this?

Suggested change
self.writer.flush().await?;
}
}
self.writer.flush().await?;

i played with this change modifying test_compressed_reading_and_writing to assert the size of the buffer. in the version in this PR, the buffer is 173 or 183 bytes depending on the value of do_close. after making the modification here, it's 146 or 156 bytes

that's not a huge savings, but it's something and i feel like we might as well. what do you think?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleting this causes run_compressed_reading_and_writing_tests(false) to fail for me, does it not for you?

it seems like when the writer isn't close()'d, without flushes we don't successfully persist all the messages to the disk. i'm not sure how regularly flushing happens if it's not called manually, but i do worry about losing unflushed messages on a crash

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be clear, i'm not proposing we remove the flush calls. i'm just proposing we move it out of the loop to give GzipEncoder a little more context to work with before flushing. the tests still pass for me after applying my suggestion here

this is definitely a nitpick tho so if you don't like this idea for some reason, feel free to ignore it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh i totally misread your initial comment, sorry! i'm happy to make the change, though fwiw in production i think pretty much all MessagesContainers are of length 1

Comment thread lib/src/qmdl.rs Outdated
hdlcs.iter().map(|hdlc| hdlc.data.len()).collect(),
)
};
for truncated_hdlc_i in 1..hdlcs.len() - 1 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason not to do one more iteration here? the tests still pass with this change

Suggested change
for truncated_hdlc_i in 1..hdlcs.len() - 1 {
for truncated_hdlc_i in 1..hdlcs.len() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, just an off-by-one on my part

Comment thread daemon/src/server.rs Outdated

let headers = [
(CONTENT_TYPE, "application/octet-stream"),
(CONTENT_LENGTH, &entry.qmdl_size_bytes.to_string()),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this content length is going to be wrong because qmdl_size_bytes is the compressed size

there are multiple ways to fix this, but i think we should just remove this header. it's inclusion comes from #253 and while having a progress bar is nice, i personally think it's more trouble than it's worth here, especially in the initial version of this

Comment thread daemon/src/server.rs Outdated
// thread.
if file_kind == FileKind::Qmdl {
copy(&mut file.take(qmdl_size_bytes as u64), &mut entry_writer).await?;
copy(&mut file.take(qmdl_file_size as u64), &mut entry_writer).await?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at least when trying to make a zip file with the current recording, this will always generate an invalid gzipped file because it's missing the footer. when i try to decompress this file with gzip, i get:

$ gunzip 1781018397.qmdl.gz
gzip: 1781018397.qmdl.gz: unexpected end of file
gzip: 1781018397.qmdl.gz: uncompress failed

can we use an approach similar to what was done for get_qmdl above and use into_qmdl_stream on the QmdlMessageReader here?

Comment thread daemon/src/diag.rs
.close()
.await
.expect("failed to close analysis writer");
match (qmdl_writer.close().await, analysis_writer.close().await) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calling close causes the qmdl_writer to add the gzip footer but qmdl_size_bytes is never updated. this is especially bad with how qmdl_size_bytes is currently used when generating a zip file because it means every .qmdl.gz file created there will be missing its footer

i also unfortunately don't think that calling qmdl_writer.size() here will work because close() will have shut down the stream. probably the quickest fix would be to check the size of the file on disk here after calling close and calling update_entry_qmdl_size with that value

what do you think?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually, i ended up going in a slightly different direction, where close() returns the final size of the file

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice! that's even cleaner imo

A few minor refactors, and a more major one that renames
RecordingStore's update_entry_qmdl_size to
update_current_entry_qmdl_size, since the only time we're ever updating
an entry's QMDL size is when it's the current one.

@bmw bmw left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this lgtm!

on top of reviewing the new commit, i manually played with this a bit more and couldn't find a way to break it

@bmw bmw merged commit 9627cec into main Jun 23, 2026
40 checks passed
@bmw bmw deleted the qmdl-gzip branch June 23, 2026 02:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants